package io.github.flypiggy.stack.operate.log.spring.boot.starter.aop;


import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import io.github.flypiggy.stack.operate.log.spring.boot.starter.context.LogOperatorContext;
import io.github.flypiggy.stack.operate.log.spring.boot.starter.context.OperatorGetter;
import io.github.flypiggy.stack.operate.log.spring.boot.starter.datasource.DatasourceApi;
import io.github.flypiggy.stack.operate.log.spring.boot.starter.exception.OperateLogException;
import io.github.flypiggy.stack.operate.log.spring.boot.starter.model.LogDTO;
import io.github.flypiggy.stack.operate.log.spring.boot.starter.properties.Exclude;
import io.github.flypiggy.stack.operate.log.spring.boot.starter.properties.OperateLogProperties;
import io.github.flypiggy.stack.operate.log.spring.boot.starter.properties.enums.ClassInfoEnum;
import io.github.flypiggy.stack.operate.log.spring.boot.starter.properties.enums.PrintLogLevelEnum;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

import static io.github.flypiggy.stack.operate.log.spring.boot.starter.properties.enums.ClassInfoEnum.VALUE;
import static io.github.flypiggy.stack.operate.log.spring.boot.starter.properties.enums.PrintLogLevelEnum.ERROR;
import static io.github.flypiggy.stack.operate.log.spring.boot.starter.properties.enums.PrintLogLevelEnum.WARN;

public class BaseAspect {

    public static final String CLASS_METHOD_SPLIT = "#";
    protected static final ObjectMapper OBJECT_MAPPER;
    protected static final JavaType JAVA_TYPE;
    /**
     * Interface exclusion.
     * Set support ? and *(wildcards) Matches.
     */
    protected static final Map<String, Set<String>> excludeApiMap = new HashMap<>();
    /**
     * Exclude a type of request method interface.
     */
    protected static Set<String> excludeHttpMethods = new HashSet<>();
    /**
     * Exclude interceptions in a packages.
     */
    protected static Set<String> excludePackages = new HashSet<>();
    /**
     * Exclude interceptions in a classes.
     */
    protected static Set<String> excludeClasses = new HashSet<>();
    /**
     * Exclude interceptions in a methods.
     */
    protected static Set<String> excludeMethods = new HashSet<>();

    static {
        OBJECT_MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL);
        OBJECT_MAPPER.registerModules(new ParameterNamesModule(), new Jdk8Module(), new JavaTimeModule());
        JAVA_TYPE = OBJECT_MAPPER.getTypeFactory().constructParametricType(List.class, String.class);
    }

    protected final Logger log = LoggerFactory.getLogger(BaseAspect.class);
    /**
     * Need to specify the exception thrown.
     */
    protected final Set<String> thrownExceptionNameSet;
    /**
     * datasource: Inject the corresponding storage data source according to the configuration.
     */
    protected final DatasourceApi datasourceApi;
    /**
     * Operator information.
     * <p>
     * {@link LogOperatorContext#get()} If it is empty, the value takes effect.
     */
    protected final OperatorGetter operatorGetter;
    /**
     * see to {@link ClassInfoEnum}
     */
    protected final ClassInfoEnum classInfoEnum;
    /**
     * Whether to be null of 'spring.operate-log.thrown-exception-name'
     */
    protected final boolean thrownExceptionNameIsNull;
    /**
     * Whether to use the swagger annotation.
     */
    protected final boolean useSwaggerAnnotation;
    /**
     * Whether to print the warning log during execution.
     */
    protected final PrintLogLevelEnum printLogLevel;
    /**
     * Whether to be null of 'spring.operate-log.exclude'
     */
    protected boolean excludeIsNull = true;
    /**
     * Whether to be null of 'spring.operate-log.exclude.http-method'
     */
    protected boolean excludeHttpMethodIsnull = true;
    /**
     * Whether to be null of 'spring.operate-log.exclude.api'
     */
    protected boolean excludeApiIsnull = true;
    /**
     * Whether to be null of 'spring.operate-log.exclude.packages'
     */
    protected boolean excludePackageIsnull = true;
    /**
     * Whether to be null of 'spring.operate-log.exclude.classes'
     */
    protected boolean excludeClassIsnull = true;
    /**
     * Whether to be null of 'spring.operate-log.exclude.methods'
     */
    protected boolean excludeMethodIsnull = true;

    public BaseAspect(DatasourceApi datasourceApi,
                      OperateLogProperties operateLogProperties,
                      OperatorGetter operatorGetter) {
        this.datasourceApi = datasourceApi;
        this.operatorGetter = operatorGetter;
        this.classInfoEnum = operateLogProperties.getClassInfoValue();
        this.printLogLevel = operateLogProperties.getPrintLogLevel();
        this.useSwaggerAnnotation = operateLogProperties.getUseSwaggerAnnotation();
        handleExclude(operateLogProperties);
        String[] thrownExceptionNameArr = operateLogProperties.getThrownExceptionName();
        thrownExceptionNameSet = thrownExceptionNameArr == null
                ? Collections.emptySet()
                : Arrays.stream(thrownExceptionNameArr).collect(Collectors.toSet());
        this.thrownExceptionNameIsNull = thrownExceptionNameSet.isEmpty();
    }

    private void handleExclude(OperateLogProperties operateLogProperties) {
        Exclude exclude = operateLogProperties.getExclude();
        if (Objects.isNull(operateLogProperties.getExclude())) return;
        excludeIsNull = false;
        // Process routing (uri and request methods)
        HttpMethod[] httpMethodArr = exclude.getHttpMethod();
        if (!Objects.isNull(httpMethodArr)) {
            excludeHttpMethodIsnull = false;
            excludeHttpMethods = Arrays.stream(httpMethodArr).map(Enum::name).collect(Collectors.toSet());
        }
        Map<HttpMethod, String[]> excludeMap = exclude.getApi();
        if (!Objects.isNull(excludeMap)) {
            excludeApiIsnull = false;
            excludeMap.forEach((k, v) -> excludeApiMap.put(k.name(), new HashSet<>(Arrays.asList(v))));
        }
        // Processing packages, classes, methods
        String[] packages = exclude.getPackages();
        if (!Objects.isNull(packages)) {
            excludePackageIsnull = false;
            excludePackages = Arrays.stream(packages).collect(Collectors.toSet());
        }
        String[] classes = exclude.getClasses();
        if (!Objects.isNull(packages)) {
            excludeClassIsnull = false;
            excludeClasses = Arrays.stream(classes).collect(Collectors.toSet());
        }
        String[] methods = exclude.getMethods();
        if (!Objects.isNull(packages)) {
            excludeMethodIsnull = false;
            excludeMethods = Arrays.stream(methods).collect(Collectors.toSet());
        }
    }

    /**
     * If {@link LogOperatorContext#get()} is not empty, {@link LogOperatorContext#get()} is used, and otherwise {@link OperatorGetter#get()} is used
     */
    protected String getOperator() {
        return StringUtils.hasText(LogOperatorContext.get())
                ? LogOperatorContext.get()
                : operatorGetter.get();
    }

    /**
     * Gets the method value of the annotation.
     *
     * @param annotation   annotation object
     * @param method       annotation method
     * @param defaultValue default value
     * @return {@link String} Annotation method return value.A non-null value
     */
    protected String getAnnotationValue(Annotation annotation, String method, String defaultValue) {
        try {
            if (Objects.isNull(annotation)) return defaultValue;
            Method api = annotation.getClass().getDeclaredMethod(Objects.isNull(method) || method.trim().isEmpty() ? "value" : method);
            Object obj = api.invoke(annotation);
            String value;
            if (obj.getClass().isArray()) {
                List<String> tags = OBJECT_MAPPER.readValue(OBJECT_MAPPER.writeValueAsString(obj), JAVA_TYPE);
                value = tags.get(0);
            } else {
                value = obj.toString();
            }
            return StringUtils.hasText(value) ? value : defaultValue;
        } catch (Exception e) {
            log.warn("OPERATE-LOG swagger annotation exception occurred in generating class name or method name!");
            commonLogPrint(e);
        }
        return defaultValue;
    }

    /**
     * Check whether the api interface is excluded.
     *
     * @param uri    uri
     * @param method http method
     * @return {@link Boolean} true need exclude; false don't need exclude.
     */
    protected Boolean isExcludeApi(String uri, String method) {
        if (excludeIsNull) return false;
        if (!excludeHttpMethodIsnull) {
            if (excludeHttpMethods.contains(method)) {
                return true;
            }
        }
        if (excludeApiIsnull) return false;
        String methodKey = excludeApiMap.keySet().stream().filter(method::equals).findFirst().orElse(null);
        if (Objects.isNull(methodKey)) return false;
        return excludeApiMap.get(methodKey).stream().anyMatch(p -> isMatch(uri, p));
    }

    protected boolean isExcludePackageClassMethod(ProceedingJoinPoint joinPoint) {
        if (excludeIsNull) return false;
        String fullPathClass = joinPoint.getSignature().getDeclaringTypeName();
        if (!excludePackageIsnull) {
            if (excludePackages.stream().anyMatch(fullPathClass::startsWith)) {
                return true;
            }
        }
        if (!excludeClassIsnull) {
            if (excludeClasses.contains(fullPathClass)) {
                return true;
            }
        }
        if (!excludeMethodIsnull) {
            String fullPathMethod = fullPathClass + CLASS_METHOD_SPLIT + joinPoint.getSignature().getName();
            return excludeMethods.contains(fullPathMethod);
        }
        return false;
    }

    /**
     * Set support ? and *(wildcards) Matches.
     *
     * @param s string to be matched
     * @param p matching rule
     * @return {@link Boolean} true is match; false isn`t match.
     */
    protected Boolean isMatch(String s, String p) {
        // Index position of 's'
        int i = 0;
        // Index position of 'p'
        int j = 0;
        // Location of backtracking when wildcards
        int ii = -1;
        int jj = -1;
        while (i < s.length()) {
            if (j < p.length() && p.charAt(j) == '*') {
                // Encountered wildcard characters, record the position, rule string 1, and locate the non-wildcard string
                ii = i;
                jj = j;
                j++;
            } else if (j < p.length() && (s.charAt(i) == p.charAt(j) || p.charAt(j) == '?')) {
                // is match
                i++;
                j++;
            } else {
                // The match failed, and it is necessary to judge whether 's' is matched by 'p'(has '*'). If it is equal to '-1', there is no wildcard before it.
                if (jj == -1) return false;
                // Return to the position where the wildcard was previously recorded
                j = jj;
                // The matching string also returns to the recorded position and moves back by one bit
                i = ii + 1;
            }
        }
        // When each field of 's' is successfully matched, it is judged that the remaining string of 'p', if it is '*', it will be released.
        while (j < p.length() && p.charAt(j) == '*') j++;
        // The match is successful at the end of detection.
        return j == p.length();
    }

    /**
     * Get access to real IP.
     */
    protected String getIp(HttpServletRequest request) {
        if (Objects.isNull(request)) {
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            request = Objects.requireNonNull(attributes).getRequest();
        }
        String ipAddress = request.getHeader("x-forwarded-for");
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("Proxy-Client-IP");
        }
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("X-Real-IP");
        }
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getRemoteAddr();
        }
        // In the case of multiple proxies, the first IP address is the real IP address of the client, and the multiple IP addresses are separated by ','
        if (ipAddress != null && ipAddress.length() > 15) { //"***.***.***.***".length() = 15
            if (ipAddress.indexOf(",") > 0) {
                ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
            }
        }
        return ipAddress;
    }

    protected void insert(LogDTO logDTOVo) {
        if (log.isDebugEnabled()) {
            log.debug("save log [{}]", logDTOVo);
        }
        try {
            datasourceApi.save(logDTOVo);
        } catch (Exception e) {
            this.commonLogPrint(e);
        }
    }

    /**
     * Build Log objects
     */
    protected void constructBaseLog(LogDTO logDTO, ProceedingJoinPoint joinPoint, HttpServletRequest request) throws JsonProcessingException {
        logDTO.setIp(getIp(request));
        logDTO.setOperator(getOperator());
        logDTO.setMethod(request.getMethod());
        logDTO.setUri(request.getRequestURI());
        logDTO.setEndPoint(joinPoint.getSignature().getDeclaringTypeName() + CLASS_METHOD_SPLIT + joinPoint.getSignature().getName());
        logDTO.setClassInfo(this.getClassInfo(joinPoint));
        logDTO.setMethodInfo(this.getMethodInfo(joinPoint));
        logDTO.setRequestBody(this.getParamsAsStr(joinPoint));
    }

    protected String getParamsAsStr(ProceedingJoinPoint joinPoint) throws JsonProcessingException {
        String[] parameterNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames();
        Object[] args = joinPoint.getArgs();
        Map<String, Object> result = new ConcurrentHashMap<>();
        if (parameterNames == null) {
            return OBJECT_MAPPER.writeValueAsString(result);
        }

        if (parameterNames.length > args.length) {
            return OBJECT_MAPPER.writeValueAsString(result);
        }

        for (int i = 0; i < parameterNames.length; i++) {
            String paramName = parameterNames[i];
            Object object = args[i];
            if (object instanceof HttpServletRequest) {
                result.put(paramName, "HttpServletRequest");
            } else if (object instanceof HttpServletResponse) {
                result.put(paramName, "HttpServletResponse");
            } else if (object instanceof MultipartFile) {
                result.put(paramName, "MultipartFile");
            } else {
                result.put(paramName, object);
            }
        }
        return OBJECT_MAPPER.writeValueAsString(result);
    }

    protected String getClassInfo(ProceedingJoinPoint joinPoint) {
        Class<?> targetClass = joinPoint.getTarget().getClass();
        String classInfo = targetClass.getSimpleName();
        if (!useSwaggerAnnotation) return classInfo;
        try {
            classInfo = this.getAnnotationValue(targetClass.getAnnotation(Api.class),
                    classInfoEnum.name().toLowerCase(), targetClass.getSimpleName());
        } catch (Throwable e) {
            exceptionLogPrint(e);
        }
        return classInfo;
    }

    protected String getMethodInfo(ProceedingJoinPoint joinPoint) {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        String methodInfo = method.getName();
        if (!useSwaggerAnnotation) return methodInfo;
        try {
            methodInfo = getAnnotationValue(method.getAnnotation(ApiOperation.class),
                    VALUE.name().toLowerCase(), method.getName());
        } catch (Throwable e) {
            exceptionLogPrint(e);
        }
        return methodInfo;
    }

    protected void exceptionLogPrint(Throwable e) {
        if (e instanceof NoClassDefFoundError) {
            throw new OperateLogException("To obtain swagger annotation exception, please check spring.operate-log.use-swagger-annotation configuration. if true is configured, you need to swagger related dependencies in!", e);
        } else {
            if (WARN.equals(printLogLevel)) {
                log.warn("OPERATE-LOG Please report the error message, we will optimize the code after receiving it.");
            } else if (ERROR.equals(printLogLevel)) {
                log.error("OPERATE-LOG This exception will not affect your main program flow, but operation logging cannot be saved.", e);
            }
        }
    }

    protected void commonLogPrint(Exception e) {
        if (WARN.equals(printLogLevel)) {
            log.warn("OPERATE-LOG Please report the error message, we will optimize the code after receiving it.");
        } else if (ERROR.equals(printLogLevel)) {
            log.error("OPERATE-LOG This exception will not affect your main program flow, but operation logging cannot be saved.", e);
        }
    }

}
